Skip to content

refactor(errors): establish user-facing error sanitization pattern (#1895) - #2293

Merged
i5hi merged 65 commits into
developfrom
1895-sanitize-all-user-facing-error-messages
Jun 27, 2026
Merged

refactor(errors): establish user-facing error sanitization pattern (#1895)#2293
i5hi merged 65 commits into
developfrom
1895-sanitize-all-user-facing-error-messages

Conversation

@ethicnology

@ethicnology ethicnology commented Jun 15, 2026

Copy link
Copy Markdown
Member

refactor(errors): establish the user-facing error sanitization pattern (#1895)

This PR is the anchor for #1895 — Sanitize All User-Facing Error Messages. It
fixes the leak in the first features and defines the error standard the rest of
the codebase converges to. The standard, snippets, and rollout are below.


Why

User-facing flows rendered raw exception text into the UI — e.toString(), node
rejection reasons, BDK/Electrum/NFC internals. Both a UX problem and an
information leak. Root causes: a legacy BullException base whose hardcoded
English message reached users; widespread catch (e) { emit(error: e.toString()) };
~219 exception classes and ~171 throw sites with no enforced "never reaches the
UI" boundary.

The standard

Three words, three jobs — kept strictly apart:

Word Meaning Lives Reaches UI?
Exception thrown, low-level, recoverable data layer ❌ caught at the boundary
Error (dart:core) programmer bug — do not catch anywhere ❌ crashes → Sentry
Failure modeled, recoverable value domain/ ✅ carried by Err, translated by the UI

One rule: exactly one try/catch, at the data boundary. Above it, a failure
is a typed Failure value, and the only thing the UI may do with it is translate it.


Error propagation by layer

Layer Job with errors
Datasource Talks to foreign code (BDK, Electrum, keychain, HTTP, drift). Throws whatever the SDK throws. Knows nothing about Failure / Result.
Repository The boundary — the only place that try/catches. Catches the foreign Exception, logs the raw reason (log.* / Sentry), maps it to a domain Failure, returns Result<T, F>. (When a feature wraps a shared core repo that still throws, this mapping happens in the feature's use-case instead — the first layer the feature owns.)
Use-case Orchestration only — no try/catch (unless it is the owning boundary above). Forwards a single Result, or composes several with an explicit short-circuit. Returns Result<T, F>.
Bloc / Cubit Consumes the Result with an exhaustive switch. Ok → success state; Err → store the typed Failure in state. No try/catch, no cast.
UI Renders failure.toTranslated(context). Never sees raw text, never switches on the type.
Datasource ──throws──▶ Repository ───Result<T,F>──▶ Use-case ──Result<T,F>──▶ Bloc ──state.failure──▶ UI
  (raw SDK)          (catch + log + map)          (forward / compose)       (switch)        .toTranslated()

A domain Failure in the bloc is not a leak: dependencies point inward
(presentation → domain → data), so presentation consuming a domain type is
correct — same as a bloc holding a Wallet. The leak we prevent is a data
exception reaching the bloc, which the repository's mapping stops.


File layout (failures in domain, translations in presentation)

The repository constructs the failure, so its type lives where the data layer can
reach it — domain/ — and stays Flutter-free. Translation needs BuildContext,
so it lives in a presentation extension, the only place that imports Flutter.

lib/features/<feature>/
├── domain/
│   └── <feature>_failure.dart       ← sealed Failure family. PURE Dart, no flutter import.
├── data/
│   └── <feature>_repository.dart    ← imports the failure; maps raw exc → Err(<Feature>Failure)
└── presentation/
    ├── <feature>_failure_l10n.dart  ← extension toTranslated(BuildContext); imports flutter + l10n
    └── bloc/                         ← stores the failure in state; UI imports the extension

domain/ and data/ stay Flutter-free and unit-testable without bindings.
Exhaustiveness is still compiler-enforced: a switch over a sealed type fails to
compile when a variant is added, wherever the switch lives.


Building blocks

1. Result<T, F> — generic over the failure type (core)

// lib/core/utils/result.dart
sealed class Result<T, F extends Failure> {
  const Result();

  R fold<R>(R Function(T value) onOk, R Function(F failure) onErr) => switch (this) {
        Ok(:final value) => onOk(value),
        Err(:final failure) => onErr(failure),
      };

  Result<R, F> map<R>(R Function(T value) f) => switch (this) {
        Ok(:final value) => Ok<R, F>(f(value)),
        Err(:final failure) => Err<R, F>(failure),
      };

  // Convert the failure type — to unify two failure domains before returning,
  // or to lift a CoreFailure into a feature failure.
  Result<T, G> mapErr<G extends Failure>(G Function(F failure) f) => switch (this) {
        Ok(:final value) => Ok<T, G>(value),
        Err(:final failure) => Err<T, G>(f(failure)),
      };
}

final class Ok<T, F extends Failure> extends Result<T, F> {
  const Ok(this.value);
  final T value;
}

final class Err<T, F extends Failure> extends Result<T, F> {
  const Err(this.failure);
  final F failure; // concrete failure type — no `as` cast anywhere
}

@useResult (package:meta) on Result-returning methods so a discarded result warns.

2. Failure base + shared CoreFailure (pure)

Failure is an open abstract base (it spans every feature). Cross-cutting modes
— recurring across the audit (network, storage-locked, not-found, timeout, auth,
device, insufficient-funds) — live once in a sealed CoreFailure, composed by
features instead of redefined 48 times.

// lib/core/failures/failure.dart — PURE  (errors/ stays the legacy graveyard)
abstract class Failure {
  const Failure([this.logMessage]);
  final String? logMessage; // logs/Sentry ONLY — never read by the UI
}

// lib/core/failures/core_failure.dart — PURE (translation extension lives in presentation)
sealed class CoreFailure extends Failure {
  const CoreFailure([super.logMessage]);
}

final class NetworkFailure extends CoreFailure {
  const NetworkFailure([super.logMessage]);
}
final class StorageLockedFailure extends CoreFailure {
  const StorageLockedFailure([super.logMessage]);
}
// NotFoundFailure, TimeoutFailure, AuthFailure, DeviceNotFoundFailure, InsufficientFundsFailure ...

3. Feature failure — pure, in domain/

// lib/features/pin_code/domain/pin_code_failure.dart — NO flutter import
import 'package:bb_mobile/core/failures/failure.dart';

sealed class PinCodeFailure extends Failure {
  const PinCodeFailure([super.logMessage]);
}

final class PinCodeSaveFailure extends PinCodeFailure {
  const PinCodeSaveFailure();
}
final class PinCodeDeleteFailure extends PinCodeFailure {
  const PinCodeDeleteFailure();
}
final class PinCodeNotSetFailure extends PinCodeFailure {
  const PinCodeNotSetFailure();
}

// A variant carrying data → fields first, then constructor (AGENTS.md ordering).
// `logMessage` is positional on the base, so forward it explicitly.
final class PinCodeFetchFailure extends PinCodeFailure {
  final String key;
  const PinCodeFetchFailure({required this.key, String? logMessage}) : super(logMessage);
}

/// Catch-all. `logMessage` is for logs ONLY and MUST never reach the UI.
final class PinCodeUnexpectedFailure extends PinCodeFailure {
  const PinCodeUnexpectedFailure([super.logMessage]);
}

4. Translation — presentation extension (the only Flutter import)

// lib/features/pin_code/presentation/pin_code_failure_l10n.dart
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/features/pin_code/domain/pin_code_failure.dart';
import 'package:flutter/widgets.dart';

extension PinCodeFailureL10n on PinCodeFailure {
  // Exhaustive: add a variant to the sealed type and this switch breaks at
  // compile time until you give it a user-safe message.
  String toTranslated(BuildContext context) => switch (this) {
        PinCodeSaveFailure() => context.loc.pinCodeSaveError,
        PinCodeDeleteFailure() => context.loc.pinCodeDeleteError,
        PinCodeNotSetFailure() => context.loc.pinCodeNotSetError,
        PinCodeFetchFailure() => context.loc.coreScreensFetchFailed,
        PinCodeUnexpectedFailure() => context.loc.oopsSomethingWentWrong, // generic, never the raw reason
      };
}

5. Repository — the boundary (catch → log raw → map → Result)

// lib/features/pin_code/data/.../pin_code_repository.dart
import 'package:bb_mobile/features/pin_code/domain/pin_code_failure.dart'; // pure, no Flutter

Future<Result<Null, PinCodeFailure>> setPinCode(String pin) async {
  try {
    await _storage.saveValue(key: _key, value: pin);
    return const Ok(null);
  } on KeychainLockedException {
    return const Err(PinCodeSaveFailure());            // foreign exception -> domain failure
  } catch (e, st) {
    log.severe(message: 'save pin failed', error: e, trace: st); // raw -> logs ONLY
    return Err(PinCodeUnexpectedFailure(e.toString()));  // generic -> UI
  }
}

6. Use-case — forward, or compose with short-circuit (no try/catch)

// pass-through: zero boilerplate
Future<Result<Null, PinCodeFailure>> execute(String pin) => _repository.setPinCode(pin);

// composing two calls from the SAME failure domain (Dart has no `?` operator)
Future<Result<bool, PinCodeFailure>> execute(String pin) async {
  final exists = await _repository.isPinCodeSet();
  if (exists case Err(:final failure)) return Err(failure); // stop at first failure
  return _repository.verifyPinCode(pin);                    // also Result<_, PinCodeFailure>
}
// Composing across repos with DIFFERENT failure types needs a `.mapErr(...)`
// step (or a shared CoreFailure) to unify F before returning.

7. Bloc — exhaustive switch, no cast; domain failure stored in state

switch (await _setPinCodeUsecase.execute(state.pinCode)) {
  case Ok():
    emit(state.copyWith(status: PinCodeSettingStatus.success));
  case Err(:final failure): // failure is PinCodeFailure — already typed
    emit(state.copyWith(status: PinCodeSettingStatus.failure, failure: failure));
}

8. UI — translate only

import 'package:bb_mobile/features/pin_code/presentation/pin_code_failure_l10n.dart';

BlocListener<PinCodeSettingBloc, PinCodeSettingState>(
  listenWhen: (p, c) => p.failure != c.failure,
  listener: (context, state) {
    if (state.failure case final failure?) {
      SnackBarUtils.showSnackBar(context, failure.toTranslated(context));
    }
  },
  child: /* ... */,
);

Conventions (mandatory)

  1. No raw exception text reaches the UI. UI renders failure.toTranslated(context) only. e.toString() / e.message is for log.* / Sentry exclusively.
  2. try/catch lives only at the data boundary — the feature's repository, or its use-case when wrapping a shared core repo. Above it: switch.
  3. Failures are sealed, one closed family per feature, declared in domain/<feature>_failure.dart, Flutter-free. Cross-cutting modes come from CoreFailure.
  4. Naming. Family <Feature>Failure; base Failure. Error is reserved for dart:core bugs only — never a domain family. Exception only for thrown infra.
  5. Translation is a presentation extension (presentation/<feature>_failure_l10n.dart) — the only place BuildContext / flutter appears for failures.
  6. Catch-all variant (<Feature>UnexpectedFailure) carries String? logMessage — logs only; the raw reason is logged at the boundary, the field is secondary (equality/debug). Keep the type String? consistently.
  7. Member ordering: fields → constructor → methods (AGENTS.md).
  8. Result<T, F> is generic over the failure type (no as casts); annotate returning methods @useResult.
  9. Every migrated use-case gets a test asserting the sanitized failure is returned for bad input (no raw leak).

Rollout plan (#1895)

This is a sanctioned, staged migration — it supersedes the prior "don't
mass-migrate exception code" guidance for the scope of #1895.

Do not migrate the high-fan-out core repos first (WalletRepository ~30
dependents, Exchange ~19, Blockchain/Swap ~12, Payjoin ~12) — that turns
incremental into big-bang. Adapt at the feature boundary: a feature's own
repo/use-case wraps the still-throwing core call locally. Core repos move last.

Future<Result<Wallet, ImportFailure>> execute(...) async {
  try {
    return Ok(await _coreWalletRepo.importWatchOnly(...)); // core still throws
  } on Object catch (e, st) {
    log.warning('import failed', error: e, trace: st);
    return const Err(ImportFailedFailure());
  }
}

Sequencing

  1. Foundation: Failure base, generic Result<T, F> + fold/map/mapErr, the CoreFailure set.
  2. Kill the leak hotspots (immediate Sanitize All User-Facing Error Messages #1895 win): status_check, legacy_seed_view, psbt_flow, all_seed_view, address_view, bip85_entropy, dca, electrum_settings, import_mnemonic, mempool_settings, onboarding, receive, sell, send, swap.
  3. Finish the near-done sealed families (add the translation extension): ledger. (labels is done — see below.)
  4. Migrate the S/M features behind adapters.
  5. The three monsters last, each its own PR + tests: send, swap, wallet.

Core / package failures (melos phase — not in #1895's feature sweep)

lib/core is shared infrastructure with no presentation/ — and post-melos it
becomes packages, which by rule never export a bloc or screen. So failures that
originate in core split two ways, and translation can't live in core.

Where they live:

  • Owned by a specific core domain (lib/core/wallet, lib/core/exchange, …) — that module owns its family in its own domain, exactly like a feature: lib/core/<domain>/domain/<domain>_failure.dart (WalletFailure, ExchangeFailure, …).
  • Cross-cutting, no single owner (network, timeout, storage-locked, not-found, auth, device, insufficient-funds) — the shared sealed CoreFailure in lib/core/failures/ (failure.dart base + core_failure.dart).

How they're translated (core has no BuildContext):

  • Default — lift into the feature's failure. The consuming feature's use-case maps the core failure into its own <Feature>Failure via mapErr, then its presentation extension translates. Core stays Flutter-free; a core failure never reaches a bloc untranslated.
    final r = await _coreWalletRepo.loadWallet(id); // Result<Wallet, WalletFailure>
    return r.mapErr(SendFailure.fromWallet);         // -> Result<Wallet, SendFailure>
  • Uniform messages — one shared extension. For modes shown identically everywhere (network / timeout), define a single CoreFailureL10n extension in a presentation-capable spot every consumer imports (today lib/core/widgets/; post-melos the app or a ui package). One definition avoids copying the same switch into N features — and the rare co-import ambiguity when two same-member extensions land in one file. The switch stays exhaustive-checked because all CoreFailure variants live in one library.

Melos-forward: the rule survives the core→packages move unchanged — a domain/data package has no presentation, so whoever owns the BuildContext (the app, a feature, or a ui package) owns the extension; the package failure stays pure.

Scope: lands with the core→packages migration, not in #1895. This PR and the
feature sweep sanitize feature-facing errors now; core/package failure families
plus the shared extension are the next phase.


Feature inventory (audit summary)

coins is empty. Effort is a fast-scan estimate — verify the L-tier before scheduling.

Already sealed + translated (finish, don't rebuild): pin_code, withdraw,
buy, recoverbull, recoverbull_google_drive, replace_by_fee,
fund_exchange, pay, broadcast_signed_tx, import_watch_only, labels
plus core-level bitbox, ledger (sealed but missing the translation layer).

Effort Features
S (~21) app_unlock, ark_setup, backup_settings, bitcoin_price, exchange, exchange_settings, exchange_support_chat, import_coldcard_q, import_qr_device, import_wallet, legacy_seed_view, pin_code, psbt_flow, recipients, recoverbull_google_drive, replace_by_fee, settings, status_check, tor_settings, withdraw, wizard
M (~18) address_view, all_seed_view, app_startup, bip85_entropy, bitbox, buy, dca, import_mnemonic, import_watch_only_wallet, labels, mempool_settings, onboarding, receive, recoverbull, sell, test_wallet_backup, transactions
L (~10) ark, autoswap, electrum_settings, fund_exchange, ledger, pay, send, swap, wallet, broadcast_signed_tx

Top three (deep chains + many variants): swap (15+ variants, 5–7 call
chain), send (8+ variants, ~7 steps), wallet (12+ variants, 20+ use-cases).

broadcast_signed_tx is the reference this PR ships and is now complete: the
legacy errors.dart is gone, the domain/presentation split and the *Failure
rename are done, and the cubit emits typed sanitized failures. It keeps try/catch
only as the data boundary for direct QR/NFC/SDK scanning, which the standard
permits (no use-case sits beneath those calls).


What this PR lands

  • broadcast_signed_tx, import_watch_only, labels fully migrated to the
    standard: sealed <Feature>Failure in domain/ (pure Dart), translation in a
    presentation/<feature>_failure_l10n.dart extension, and Result<T, F>
    propagation (use-cases/repos return it; cubits switch). The real leaks are
    removed — transaction_review_view's coreScreensUnexpectedError(message ?? 'unknown')
    and the BIP329 labels cubit's raw 'Export failed: $e'.
  • State types narrowed from Exception? / String to the concrete sealed Failure.
  • import_watch_only: the inline satoshifier parse is extracted into
    ParseWatchOnlyInputUsecase returning Result, so the cubit holds no try/catch.
  • labels: the facade returns Result on writes (store/trash) and stays
    best-effort on reads (degrade to empty + log) so the ~15 core wallet read sites
    are untouched; unused LabelFailure variants pruned.
  • pin_code: the failure extension is renamed to pin_code_failure_l10n.dart
    (naming convergence; pin_code itself was already on the standard).
  • Localization: added the new *Error* keys; removed dead keys
    (coreScreensUnexpectedError, labelErrorUnexpected, and the pruned label
    variants' keys).
  • docs(agents + architecture): member-ordering convention, the _failure_l10n
    naming, and the Result/Failure standard.
  • New use-case tests asserting the sanitized failure on bad input (import
    descriptor/xpub/parse, broadcast build_reviewable, all four labels use-cases).

Convergence note. These three features are now fully on the standard
<Feature>Failure naming, the domain/+presentation/ split, and Result<T, F>
propagation — and serve as the reference implementation the rest of the rollout
copies. They no longer use the legacy <Feature>Error / toTranslated-on-the-error
/ throw-based shape. The NFC collapse (PushTxNoNdefRecordsError /
PushTxNoUriError / PushTxMissingFragmentParamsError → one InvalidPushTxFailure)
is a deliberate granularity trade for sanitization.

@ethicnology ethicnology linked an issue Jun 15, 2026 that may be closed by this pull request
59 tasks
@claude

This comment was marked as outdated.

@ethicnology
ethicnology force-pushed the 1895-sanitize-all-user-facing-error-messages branch from b83bad3 to 4b853f9 Compare June 18, 2026 07:02
ethicnology and others added 23 commits June 22, 2026 14:41
Replace raw e.toString() in state and UI with a sealed ImportWatchOnlyError family whose variants own their localized toTranslated(BuildContext). Foreign import failures are mapped and logged at the usecase boundary; the catch-all shows a generic message. Also renames the usecase call() to execute() to match convention.
Replace the hardcoded-English BullException errors and the Exception?-typed state with sealed BroadcastSignedTxError and TransactionReviewError families that own their localized toTranslated. Scan/NFC/broadcast/parent-fetch failures are mapped and logged at the owning layer; the UI no longer renders state.error.toString() or a raw catch-all message.
LabelError becomes a sealed family with per-variant toTranslated. The unexpected catch-all now returns a generic localized message instead of the raw exception text, and the label usecases log the technical reason at the mapping site.
Add the new import_watch_only and broadcast_signed_tx error keys plus the regenerated labelErrorUnsupportedType and broadcast failure copy across all 27 locales. Remove the retired labelErrorUnexpected, the stale {type} placeholder, and the orphaned coreScreensUnexpectedError. Non-en/fr translations are AI-generated and pending native-speaker review.
- Result is now generic over its failure type (Result<T, F extends Failure>)
  so consumers no longer cast (drops `failure as PinCodeError`); add
  fold/map/mapErr helpers.
- Move the Failure base to lib/core/failures/ (Flutter-free).
- Rename PinCodeError to a pure sealed PinCodeFailure in domain/; Error is
  reserved for dart:core bugs.
- Move toTranslated into a presentation extension (pin_code_failure_x.dart)
  so domain and data stay Flutter-free.
- Repository maps exceptions to failures at the boundary; the bloc switches
  on Result and stores the typed failure in state.
Establish the sanitized error standard for #1895 across both rulebooks.

ARCHITECTURE.md (Error handling section, glossary, feature template,
checklist, enforcement table) and AGENTS.md (rule #11, naming, files
table, rule #15) now specify:

- Three distinct kinds: Exception (thrown infra, caught at the boundary),
  Error (dart:core bug, never caught), Failure (modeled recoverable value
  in domain/). Name domain failures <Feature>Failure, never <Feature>Error.
- Result<T, F extends Failure> (Ok/Err, generic over F, @useResult,
  fold/map/mapErr); throw only for dart:core bugs.
- Failures are Flutter-free in domain/; translation is a presentation
  extension (<feature>_failure_x.dart), never a method on the failure.
- Repository is the one try/catch boundary (or the feature use-case when
  wrapping a shared core repo); bloc switches and holds the typed failure.
- Cross-cutting modes via shared CoreFailure in lib/core/failures/.
- #1895 migration is sanctioned and staged; existing BullException,
  *Error naming, on-error translation and throw-based code is
  legacy-to-converge.
- Rename ImportWatchOnlyError to a pure sealed ImportWatchOnlyFailure in domain/; Error is reserved for dart:core bugs.
- Move toTranslated into a presentation extension (import_watch_only_failure_l10n.dart) so domain and data stay Flutter-free.
- Use-cases wrap the throwing core WalletRepository, map the raw reason to ImportFailedFailure at the boundary, and return Result<Wallet, F>.
- The cubit switches on Result and stores the typed failure in state; no exception text reaches the UI.
- Update the descriptor use-case test to assert the sanitized failure on bad input.
- Replace the two error families with pure sealed BroadcastSignedTxFailure and TransactionReviewFailure in domain/; Error is reserved for dart:core bugs.
- Move toTranslated into presentation extensions (*_failure_l10n.dart) so domain stays Flutter-free.
- BuildReviewableTransactionUsecase maps the foreign TransactionPortError at the boundary and returns Result; add its unit test.
- State carries the typed failure; the cubits switch on Result. The broadcast cubit keeps try/catch only as the data boundary for direct QR/NFC/launchUrl calls.
- Remove the legacy broadcast_signed_tx_error.dart and domain_errors.dart.
- Rename LabelError to a pure sealed LabelFailure in domain/; move toTranslated into a presentation extension (label_failure_l10n.dart).
- The four facade use-cases map the throwing repository port at the boundary and return Result; add their unit tests.
- Facade keeps reads best-effort (degrade to empty + log, never throw) and exposes Result on writes (store/trash), so the ~15 wallet read sites are untouched.
- Fix the real leak: the BIP329 cubit no longer renders raw 'Export failed: $e'; it logs the reason and emits a sanitized LabelUnexpectedFailure.
- transaction_details_cubit switches on the write Results (required for the facade signature change to compile).
- Rename pin_code_failure_x.dart to pin_code_failure_l10n.dart so the translation-extension file name states intent; sets the naming the rest of the rollout follows.
- Update the translation-extension references from <feature>_failure_x.dart to <feature>_failure_l10n.dart across the architecture and agent docs, matching the shipped code.
- Refresh the member-ordering example off the legacy UnexpectedLabelError / toTranslated-on-the-error pattern that the sanitization rollout replaces.
- Remove LabelNotFoundFailure, UnsupportedLabelTypeFailure and SystemLabelCannotBeDeletedFailure — modeled and translated but never constructed anywhere.
- Remove their now-orphaned l10n keys (labelErrorNotFound, labelErrorUnsupportedType, labelErrorSystemCannotDelete) from the arb files.
- LabelUnexpectedFailure, the catch-all that is actually produced, is the only remaining variant.
- Add ParseWatchOnlyInputUsecase that wraps the throwing satoshifier parser at the boundary and returns Result<WatchOnlyWalletEntity, ImportWatchOnlyFailure>, mapping a parse error to InvalidFormatFailure.
- The cubit injects it and switches on the Result, so parsePastedInput no longer holds a try/catch.
- Add a use-case test asserting the sanitized failure on unparseable input.
- Assert ImportWatchOnlyXpubUsecase maps a foreign repository failure to ImportFailedFailure without leaking the raw exception, and returns Ok on success.
- Remove ImportWatchOnlyUnexpectedFailure: it was declared and translated but never constructed (the use-cases map every throw to ImportFailedFailure), consistent with dropping the unused LabelFailure variants.
- ImportFailedFailure is import's effective catch-all.
- Best-effort reads no longer log twice: the facade drops its read-fold log.warning since the use-case already logs the failure once with the stack trace.
- deleteTransactionNote returns its Result so the labels table item can surface a sanitized message on a failed delete instead of silently keeping the note.
- Export label_failure_l10n from the facade so consumers translate LabelFailure; drop the now-redundant direct import in labels_widget.
- Replace the fabricated LabelFetchFailure example (and its contrived toString) with the Amount value object already used in ARCHITECTURE.md. Failures are field+constructor only now, so they don't illustrate the methods group; a value object shows fields -> constructor -> method without inventing a fake type.
wired-pasteque and others added 25 commits June 24, 2026 09:42
…rors

refactor(bip85_entropy): sanitize errors with sealed failures and a Result boundary
…-errors

refactor(mempool_settings): sanitize errors with sealed failures and a Result boundary
…rors

refactor(all_seed_view): sanitize user-facing error messages
Resolved conflicts:
- lib/core/fees/data/fees_datasource.dart: kept the Result-based active mempool server lookup (.fold) from this branch, since GetActiveMempoolServerUsecase returns Result<MempoolServer, MempoolFailure> here.
- 27 localization/app_*.arb files: kept this branch's sanitized error strings (no raw {reason}/{error} placeholders) and added develop's 166 new keys (coins/UTXO, bitbox bluetooth, logs viewer, labels, etc.).
- test/core_test/fees/fees_datasource_test.dart (new in develop): wrapped the settings-repo mock in Ok() to match the Result-based fetchByNetwork signature.

Regenerated l10n + build_runner outputs. flutter analyze clean; full test suite (483 tests) passes.
The Result migration destructured the usecase result as `mnemonic`,
shadowing the outer default-wallet mnemonic the verification
re-derivation relies on. Rename to `resultMnemonic` (as the sibling
test already does) so the direct BIP85 derivation uses the same seed
the usecase derives from.
…user-facing-error-messages

# Conflicts:
#	lib/features/import_watch_only_wallet/presentation/scan_watch_only_screen.dart
…errors

refactor(import_mnemonic): sanitize user-facing error messages
…rrors

refactor(replace_by_fee): sanitize user-facing error messages
@ethicnology
ethicnology marked this pull request as ready for review June 25, 2026 17:02
@ethicnology
ethicnology requested a review from i5hi June 25, 2026 17:02
@i5hi
i5hi merged commit 3c6a71d into develop Jun 27, 2026
1 check passed
wired-pasteque added a commit that referenced this pull request Aug 24, 2026
Four points where the ledger implementation drifted from the standard in #2293:

The failure family moves to domain/ and the exception family to data/, matching
the layer each word belongs to — a Failure is a domain value, an Exception is
thrown infra.

The catch-all now returns the shared oopsSomethingWentWrong instead of a
ledger-specific "unknown error" string, so the redundant key is gone from all
27 locales.

ConnectionTypeNotInitialized no longer maps to the generic failure. The
transports are nullable and initialized during scan, so this fires on a
reachable path and means "no connection available" — which is actionable,
unlike "Oops, something went wrong".

The APDU status word is only read when it is labelled (0x6985, sw=6985). The
previous pattern matched any four hex-ish characters, so "timeout after 6985
ms" was reported to the user as "you rejected the operation on the device".

Also drops the last `dynamic` from the operation seam and closes the repository
contract with `abstract interface class`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sanitize All User-Facing Error Messages

4 participants